Files
rose-ash/plans/relations-as-posts.md
giles 999249b944 host: SX-native wire — reads + write bodies are text/sx, JSON CRUD deleted
Greenfield SX-native pivot (NOT a strangler): the host speaks SX/SXTP end to end;
JSON only at the future ActivityPub federation edge.

- OUTPUT: host/json-status -> host/sx-status — every host/ok/host/error response is
  text/sx via the serialize primitive (NOT application/json). Flips feed, relations,
  blog reads. Tests assert the SX envelope ({:ok true :data ...}).
- DELETE the blog JSON CRUD /posts (POST/PUT/DELETE) + bearer-based host/blog--protect:
  a pure old-contract REST mirror. Create/edit go through the HTML editor forms;
  programmatic writes speak SXTP. FOLLOW-UP: no browser delete route yet (was JSON-only,
  no UI) — add POST /:slug/delete + cascade edge cleanup when the metamodel UI needs it.
- INPUT: host/sx-body (sxtp.sx) parses a text/sx request body to a string-keyed dict
  (parse-safe + sxtp/-normalize). feed POST + relations attach/detach read it.
- UNIFIED field reader host/fields / host/field: text/sx body OR urlencoded form by
  content-type. The blog form handlers (new/edit/relate/unrelate) + login read through
  it — additive, urlencoded still works (no-engine / bootstrap fallback).

Conformance 290/290 (11 suites). Retires the strangler framing in the plan; adds the
'SX all the way out' wire table. The engine half (browser posts text/sx) follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:07:30 +00:00

345 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Relations as posts — declared, inherited, and eventually algebraic
## Principle
Everything is a post in one graph: content-posts, type-posts, **relation-posts**, and
(later) **constraint-posts**. Nothing about typing is hardcoded — a type-post *declares*
which relations it anchors, declarations are *inherited* down the type closure, and
every candidate set / validation is a transitive graph query (`lib/relations`). This
closes the meta-circular loop the typing plan gestured at: the type system describes
itself in its own graph.
Supersedes the hardcoded `:candidates "types"/"tags"/"all"` field of `host/blog-rel-kinds`.
## Content-addressability is universal (foundational)
**Every object carries a content-address (CID) — content-posts, type-posts, relation-posts,
constraint-posts, all of them.** A CID is the hash of the object's *canonical* form: a recursive,
**key-sorted** serialization (so insertion order, and any process-seed-dependent dict ordering, is
irrelevant — identical content always yields an identical CID). The runtime has no hash primitive,
so the canon serializer + a tail-recursive double-hash are built in SX (`host/blog--canon`,
`host/blog--cid-of`); the slug is excluded from the hash (it's a *name*, not content).
The model is **git-shaped**: the **slug is a mutable name → CID** (a branch pointing at a commit);
the **CID is the immutable content identity** (the commit). Editing a post mints a new CID; the slug
follows. Type evolution is the same — a type *version* is content-addressed, instances reference the
version they were created against. Two objects with identical content *are* the same object (same
CID) — correct content-addressing semantics.
**Why it's foundational (federation).** A CID is a **global, location-independent identity**, so:
- **Types flow across `fed-sx`.** The same type *definition* on any node has the same CID → a
**shared, content-addressed vocabulary**. Federated *instances* reference type CIDs, so a receiving
node can *interpret* them. This is linked-data/RDF realised on the post graph, and it generalises
ActivityPub itself: AP has a *fixed* type vocabulary (Note/Article/Person, Create/Follow/Like) —
the metamodel makes that vocabulary **extensible and user-defined**.
- **Structure / behaviour trust-split** (the federation boundary): type **structure** (schema,
relations, signatures) is declarative and federates *freely* — sharing a definition is sharing a
hash. **Behaviour** (Slice 9 lifecycles/effects) does **not** federate naively: you never run a
remote node's lifecycle with *your* effect primitives (their "ship" could `charge-card`).
Behaviour federates only under high trust, with the effects **re-bound** to local, audited
primitives (their orchestration, your effects). `fed-sx` is already trust-gated — that's the lever.
Build order: stamp a stable CID on every object first (additive — slug-addressing stays the working
key), then a `cid → slug` index, then migrate references / type versioning, then federation.
## North star — the metamodel as a system-construction kit
The destination this is all heading toward: the host stops being "a blog" and becomes a
**self-describing metamodel**. You *define a domain* — types (with schemas/refinements) and
relations (with role signatures + algebra) — and a working system falls out. The blog content
is one seeded configuration; clear it and define different types and you have a different system
on the same engine. Framework, not application (cf. `[[feedback_runtime_control]]`,
`[[project_zero_dependencies]]`).
Most of the **instance UI is already generic** — the edit page's relation editors are generated
by iterating the relations; each picker's candidates come from the relation's `declares`-anchor /
role type; validation comes from the type's `:schema`. So once Slices 67 land, "define the
types" through a UI is mostly two surfaces, plus a reset:
1. **Metamodel editor** — create a type-post (give it a schema/refinement); create a relation-post
(give it a role signature + algebra). The thing that lets you *construct* a system.
2. **Generic instance form** — create/edit any post of any type, driven entirely by the
definitions above (the relation editors + pickers + save-time validation we already have).
3. **Clear-and-reseed** — wipe instance data, seed only the metamodel roots (`type`, `relation`,
the core relations); start from a bare kit and build a domain up from nothing.
Sequence: finish the schema language (Slices 67) → the two UI surfaces + reset → clear the demo
data and define a real domain through the UI. The slices below are the schema language; this is
what it's *for*.
### Endgame — the whole platform as a typed domain (greenfield, not a strangler)
Not just the blog: the entire rose-ash platform — **store, events, orders, cart, …** — is
expressible as type + relation definitions in this one metamodel. `Product`, `Event`, `Order`,
`Ticket` are types; "cart has line-items", "order for an event", "ticket of an event" are
relations with signatures (cardinality = a cart has many line-items, a ticket belongs to one
event). This is NOT a strangler off Quart (`[[project_host_on_sx]]`) — it's a **greenfield,
SX-native system**: define the domain schema as data from first principles, then **port the data
once at the end** (define-then-port), rather than reimplementing each service's bespoke models
endpoint-by-endpoint. The strangler's compatibility machinery (JSON mirrors, route/model parity,
incremental contracts) is dropped — it was tax, not value, for a system that doesn't *correspond*
to the old one.
### SX all the way out — no JSON on the internal wire
The platform speaks **SX/SXTP end to end**, both directions, browser included — JSON survives only
at the ActivityPub federation edge (JSON-LD, a published external standard).
| Layer | SX-native form |
|-------|----------------|
| Page render | HTML (the document itself) |
| Data reads | `text/sx` via the `serialize` primitive (`host/ok`/`host/error``host/sx-status`) |
| Write bodies | `text/sx` parsed via `sxtp/parse` (was JSON / form-urlencoded) |
| Browser → server | the engine posts `text/sx` (boosted forms serialise fields to SX wire); form-urlencoded survives only as the **no-engine / pre-hydration fallback** + the **login bootstrap** handshake |
| Federation edge | JSON-LD (ActivityPub — the *only* JSON) |
The blog **JSON CRUD `/posts`** (POST/PUT/DELETE) is **deleted**, not converted: it was a pure
old-contract REST mirror; writes go through the HTML editor forms + SXTP.
Three honest additions store/events surface (the blog didn't need them):
1. **Typed scalar ATTRIBUTES, not just entity relations.** A `Product` needs `price: Money`,
`sku: String`, `stock: Int`; these are *values*, not edges to posts. We've built RDF
*object properties* (edges to resources); this needs *datatype properties* (literals with
value-types + validation). So a type declares **fields** `{field, value-type, card, required,
validation}` alongside relations; instances carry typed values; value-types (`Money`, `Int`,
`DateTime`) are primitive types. Same shape as a role — a role points at a *type*, a field
holds a *value-type*. **This is a real addition to a/b/c+d** and likely Slice 8.
2. **Behaviour / lifecycle** (order `pending→paid→shipped`) is NOT structure — it's the
substrate loops: `[[project_flow_on_sx]]` (durable workflows), `[[project_commerce_on_sx]]`,
`[[project_events_on_sx]]`. The metamodel *attaches behaviour to types by composing those*,
not reinventing them.
3. **Integrations** (SumUp payments, ActivityPub federation, artdag media) — types *reference*
these services; they don't dissolve into posts.
So the complete picture: the metamodel expresses **structure + validation** of the whole
platform's domain model uniformly; **behaviour composes from the substrate loops**;
**integrations stay referenced services**. It's the convergence point of every loop in the repo.
## Behaviour as data — lifecycles + ECA over an effect vocabulary (DESIGN — Slice 9)
Structure is inert; "place an order / ship goods" is the dynamic part. The principle:
**behaviour is data-defined orchestration over a small fixed vocabulary of effects.** Only two
layers stay code — the **effect primitives** (the irreducible ops that touch the world) and the
**interpreter** that runs the data. Everything between is editable posts. The system defines its
own behaviour down to the effect boundary (`[[feedback_runtime_control]]`).
**Shape.** A type declares a **lifecycle** (a state machine) as data, plus standalone **ECA
rules** for reactions that aren't state transitions:
```
Order: cart --place--> placed [guard: stock-available ∧ total>0] [effects: reserve-stock]
placed --pay--> paid [guard: payment-ok] [effects: charge-card, confirm-stock]
paid --ship--> shipped [guard: address-valid] [effects: create-shipment, notify]
ECA: when stock(product) < threshold => notify(buyer:owner, "restock")
```
- **States/transitions/rules/effect-invocations are all posts** — meta-circular: `Lifecycle`,
`Transition`, `Rule`, `Effect` are themselves types in the metamodel; a behaviour is instances
you edit in the same UI as the schema. A transition = `{from, to, on-event, guard, [effects]}`.
- **Guards are PURE** — predicates over the instance's attributes/relations, i.e. type-system
queries (Datalog). No side effects, analysable, you can diagram a lifecycle.
- **Runs on `[[project_flow_on_sx]]`** because it's durable + long-running: `placed→paid` waits
for a SumUp webhook, `paid→shipped` waits days. flow's suspend/resume IS this. Failures →
compensation (saga) — `commerce-on-sx` already does "refund as a flow".
- "Place an order" / "ship" = *attempt transition T*; the button/webhook just fires the event.
### The effect vocabulary (sketch — store + events)
An effect is a named, parameterised op (itself an `Effect` post: name + params + binding).
Behaviours reference effects by name with args bound to instance/context. Four tiers:
| Tier | Effect | Notes |
|------|--------|-------|
| **Pure guard** (read-only, not an effect) | `is-a? / attr-cmp / count / relation-exists?` | type-system queries (Datalog); compose the transition guards |
| **Data** (internal, transactional on the graph) | `create(type, attrs)`, `set-attr`, `set-state`, `relate / unrelate`, `incr / decr`, `append-ledger(entry)` | the durable post-graph mutations; `decr` stock is atomic-with-check |
| **Domain** (composed from data, named for atomicity/meaning) | `reserve-stock`, `release-stock`, `confirm-reservation`, `book-seat`, `issue-ticket` | small compositions the vocabulary blesses; `events-on-sx` has the capacity-safe versions |
| **Integration** (external services — the code edge) | `charge-card`, `refund` (SumUp), `create-shipment` / `track`, `notify(recipient, template, data)`, `federate(activity)` (ActivityPub), `process-media(asset)` (artdag) | the irreducible primitives; keep this list SMALL and composable (artdag's S-expression effects is the model) |
| **Control** (durable orchestration — flow primitives) | `wait-for(event)`, `wait-until(time) / after(dur)`, `emit(event)`, `transition(instance, state)` | `wait-for` = the SumUp webhook / shipment-delivered; `after` = reservation-expiry / event-reminder; `emit` chains ECA rules |
So `place order` = guard `stock-available ∧ total>0` → effects `reserve-stock`, `set-state placed`,
`emit order-placed`; the webhook later fires `pay``charge-card`, `confirm-reservation`,
`set-state paid`. Events reuse the same machinery: ticket `reserved →(after 15m, no pay)→ released`,
event `--remind(after)--> notify` digests. Almost all of it is the same vocabulary.
### The one fork (same shape as the type-system line)
- **Declarative core** — lifecycles + ECA + the effect vocabulary: safe, analysable, diagrammable,
editable by non-programmers, verifiable. Covers ~95%.
- **Guarded code escape-hatch** — a `Scheme`/`Smalltalk` snippet stored on a post and `eval`'d for
the rare bespoke guard/effect (`[[project_content_on_sx]]` is Smalltalk message-passing,
`[[project_flow_on_sx]]` is guest Scheme — the homoiconic door exists). Turing-complete, unsafe,
fenced — exactly the decidable-core / fenced-frontier split we drew for types.
**Where to start:** pin down the effect vocabulary above (the real design artifact), build the
generic interpreter on flow-on-sx with pure (Datalog) guards, and **lift `commerce-on-sx` /
`events-on-sx` from guest-code into lifecycle+effect DATA** — they already implement exactly this,
just not editably.
## Why (the wrinkle that started this)
Candidates for `is-a`/`subtype-of` were `instances-of("type")` — the *instances* that are
types, but NOT the type-defining posts themselves (`type`, `tag`, `article` are wired with
`subtype-of`, no `is-a` edge, so they're not instances of type). So the picker offered
`tutorial` (is-a tag) but never `tag`/`article`/`type` — the things you most want to say a
post *is-a*. The fix is to ask the right question: a candidate is anything that **inherited
the relation's object-end declaration from the anchor**, which includes the roots.
## Model
- A **declaration** is an edge `T --declares--> R`: type-post `T` anchors relation `R` at
its **object** end ("you may point *at* `T` with `R`"). Seed: `type declares is-a`,
`type declares subtype-of`, `tag declares tagged`. `related` has no declaration.
- **Candidate set** for relating under `R` = the **down-closure** of `R`'s anchors through
`inverse(is-a) inverse(subtype-of)` (a post is a candidate iff it is, transitively, an
instance-or-subtype of an anchor — or IS one). No anchors ⇒ every post (`related`).
- `is-a`/`subtype-of`: anchors `{type}` ⇒ the whole type closure (roots + subtypes +
instances). **Wrinkle fixed.**
- `tagged`: anchors `{tag}` ⇒ the tags.
- `related`: no anchor ⇒ all posts.
## Roadmap
### Slice 1 — declarations + candidate-by-inheritance — DONE
- Seed `declares` edges; add `host/blog--reach-down` (down-closure) and rewire
`host/blog--candidate-pool` to be declaration-driven. `:candidates` becomes vestigial.
- Wrinkle fixed: the type roots now appear as `is-a` candidates.
### Slice 2 — relations as first-class posts — DONE
- `relation` root + `is-a`/`subtype-of`/`tagged`/`related` seeded as posts (each is-a
relation) owning their metadata in a `:rel` slot (`:symmetric :label :inverse-label`).
`host/blog-rel-kinds` / `kind-spec` / `kind-symmetric?` now read it; the static registry
is gone. `host/blog--rel-slugs` = `host/blog-in "relation" "is-a"` (cheap, flat).
- **Perform budget under http-listen (the hard lesson):** a durable read inside the
render VM raises `VmSuspended`, and too many per request 500s the page. Two fixes:
(1) relation metadata is loaded into an in-memory cache at boot (`host/blog-load-rel-kinds!`,
like `load-edges!`) so `kind-spec` is pure; (2) the initial edit page renders its pickers
EMPTY (the load trigger fills each) — only the relate/unrelate FRAGMENT server-renders
candidates (`with-cands` flag), so one page render doesn't do `candidate-get × every
picker`. Benign single-perform suspend/resume still logs `VmSuspended` but returns 200.
- **Live JIT gotcha (cost real time):** the serving-mode JIT drops all-but-first when
`map`/`for-each`-ing a *function-produced* list — building `rel-kinds` that way rendered
only 1 of 4 editors live, while conformance + the ephemeral server passed. So
`host/blog-rel-kinds` is a VALUE the boot populates and the cache loads are UNROLLED.
**Conformance green ≠ correct live — verify the rendered edit page.** (Re-fold the
enumeration once plans/jit-bytecode-correctness.md lands.)
### Slice 2.5 — picker title reads are O(page), not O(pool) — DONE
- `relate-candidates` computes the available candidate SLUGS (slug-sorted, no per-candidate
read), then reads titles ONLY for the page it returns. On the unfiltered path (q="" — the
initial picker load AND every editor server-fill, the common case) that's ~`limit` reads
instead of one-per-post — killing the durable-read churn under http-listen. A filter
(q≠"") still resolves titles across the pool (it matches on the title), but that's the
interactive path.
- A boot-time slug→title **cache** would make even the filter O(1)-perform, BUT it's blocked
for now: there's no bulk KV read, and a per-post `host/blog-get` loop **at boot** hits the
JIT bug (a durable read inside a boot loop drops all-but-first — `load-edges!` only works
because its loop body is perform-free). Revisit with a bulk read or once the JIT lands.
**Remaining follow-ups:** subject-end declarations (who may be the *source*); a proper
relation-subtype closure when relations get subtyped; the boot title cache above.
### Slice 3 — typed relations (target-type constraints) — DONE
- The declaration's `declares`-anchor IS the target-type constraint: `is-a`/`subtype-of`
(anchored by `type`) require a type object; `tagged` (anchored by `tag`) a tag. A new
`wrote` relation needs only a `Work declares wrote` edge — fully data-driven.
- `host/blog--valid-object?(kind, other)` = `other ∈ candidate-pool(kind)` — the SAME set
the picker offers, so picker and validation agree by construction. `relate-submit` now
enforces it (an invalid target is a silent no-op, like the other guards); `related`
(no anchor) accepts any post. The picker never offers an invalid target, so this guards
crafted/API requests — the jump from "candidate set" to an enforced relation schema.
- NOTE: `host/blog-relate!` (direct/seed) stays UNVALIDATED — the seed needs to write
`X is-a relation` where `relation` isn't under `type`. Validation is a *handler* boundary.
### Slice 4 — type algebra — DONE (intersection ∧ union)
- An algebraic type is a post with operand edges: `conj` edges (intersection members),
`disj` edges (union members). `host/blog-instances-of-expr` computes its EXTENT from the
operands' extents by set intersection / union, RECURSIVELY — so operands can themselves be
algebraic (meta-circular; tested with `(tag ∧ article) ∧ tag`). `host/blog-is-a-expr?`
generalises `is-a?` to type expressions. `host/blog-make-and!` / `make-or!` build them.
- Binary today (`nth 0/1`, no fold over operands — robust on the serving JIT); n-ary fold is
a follow-up once iteration-with-perform is JIT-reliable.
- **Operand edges are KV-only** (`host/blog--add-edge-kv!`, read via `host/blog-out`), NOT in
lib/relations — feeding extra kinds into the Datalog graph blows up its per-query
re-saturation; `load-edges!` skips `conj`/`disj` on replay for the same reason.
- **Refinement** `{x : T | φ(x)}` (a type-post with a `:constraint` predicate) → Slice 5,
with constraints-as-posts. (Process note: a sibling loop running heavy conformance saturates
the box; host conformance can EXIT 124 purely from CPU contention — use `timeout 1200`.)
### Slice 5 — refinement types (schemas ON the type-post) — DONE
- A type-post carries its schema in a `:schema` slot (a list of `{:block :msg}` rules —
a refinement `{x : T | x has these blocks}`). `host/blog-schema-of` reads it off the
post; the hardcoded `host/blog-type-schemas` table is gone. A NEW refinement type is pure
data: give a type-post a `:schema` (`host/blog--set-schema!`) and its instances are
validated on save against it — no code. Tested with a `guide` type requiring a `pre` block.
- Save-time validation (`type-issues`/`type-valid?`, the only callers, in the SAVE request)
unions the schemas of a post's full transitive type set — unchanged, just sourced from the
posts. `schema-of` reads the post (a durable read) — fine in the save request, never render.
- `host/blog-put!` now MERGES over the previous record, so editing a post's title/content
doesn't nuke its `:schema` / `:rel` metadata (also closes the Slice 2 "edit drops :rel" gap).
- `article`'s schema migrated onto the article post (`set-schema!` at boot — a single
read+write, not a loop, so boot-JIT-safe; idempotent, handles the already-seeded article).
- FUTURE: arbitrary predicate constraints (not just required blocks); constraints as their
own posts; relation cardinality (`is-a` single-valued?) as a declared constraint.
## Parameterised relations (DESIGN — Slices 6 & 7)
The next axis: `Relation<…>`. The key reframe is that the obvious parameters aren't separate
`<N>`s — they split into **two halves**, and they compose into one coherent thing:
1. **The role SIGNATURE** (the *shape* of a tuple) — Slice 6 (a + b + c).
2. **The relation's ALGEBRA** (how it *behaves*) — Slice 7 (d).
A relation is `Relation<signature>`, where a signature is an ordered list of **roles**, each
role carrying a **type** and a **cardinality**; the signature's length is the **arity**.
Today's binary typed relations are the degenerate 2-role case — backward-compatible, nothing
gets thrown away. Prior art to borrow (and stay decidable within): Codd / ER reified
relationships (signature), OWL property characteristics (algebra), Datalog / relation algebra
(derived relations — the undecidable frontier; fence it). Decidability rule of thumb: concrete
+ algebraic role-types and counts stay decidable; arbitrary predicates / recursive rules don't.
### Slice 6 — the role signature (a + b + c)
Generalise the relation-post's `:rel` slot from `{:symmetric :label}` to a `:roles` list —
`{:roles [{:name :type :card} …]}` — driving picker candidates, validation, and arity per-role:
- **(a) per-role type** — each role's `:type` is a type-expr (so it can be algebraic:
`Relation<Work ∧ Published>`). The object-role's type IS today's `declares`-anchor — make it
explicit. `valid-object?` becomes per-role `is-a-expr?` against `:type`.
- **(b) arity** = `(len roles)`. Binary stays the fast `src|kind|dst` edge path; **n-ary needs
reification**: a relation *instance* becomes its own post with role edges (`subject→X`,
`object→Y`, `recipient→Z`) — on-brand (we made relation *kinds* posts; now *instances* too),
but a SECOND representation alongside the binary edges, not a tweak. Qualifiers (Wikidata-
style) then come free as extra roles.
- **(c) cardinality** — `:card` per role (min/max; functional = max 1, required = min 1),
enforced on relate by counting. Composes with Slice 5 validation. No model change for binary.
- Siblings: ordered roles (set vs list), keys/identity (which roles identify a tuple).
- **Layering (cheapest → deepest):** (c) cardinality on the binary object-role → (a) explicit
role-type + the 2-role signature abstraction → (b) reified n-ary (the real lift).
- **Variance: nominal, none initially** — no structural subtyping of `Relation<…>` (covariance
of parameterised types is a research project). JIT caveat: 2-role signatures are unrollable;
n-ary role-iteration with per-role reads needs the cache/unroll treatment (Slice 2/5 lesson).
### Slice 7 — relation algebra / characteristics (d)
The behaviour half — and (d) **transitivity** is special because we ALREADY hardcode it
(`is-a`/`subtype-of` closure via lib/relations); declaring it generically *removes* code.
- **Algebraic properties** declared on the relation-post (`:transitive :symmetric :reflexive
:antisymmetric :irreflexive`), with the closure **derived generically** from them — OWL's
property characteristics. `subtype-of` becomes "a declared transitive + antisymmetric
relation" (a partial order), not a special case. `:symmetric` (already stored) folds in here.
- **Inverse relations** — a real `:inverse` (not just the `:inverse-label` display hint):
relating one auto-derives the converse, the way `:symmetric` writes both directions.
- **Sub-relations** — relations subtyping relations (`wrote subPropertyOf created`): X wrote Y
⟹ X created Y. Same `subtype-of` machinery, over the `relation` root — meta-circular.
- **Decidable core stops here.** Beyond-d, FENCED: defined-by-rule relations (composition,
`grandparent = parent ∘ parent` — straight onto the Datalog substrate, but gate to
stratified/bounded rules) and cross-role refinement predicates (`start < end`) — both need
the predicate-language-vs-embedded-code decision first.
## Open design questions (track as we go)
1. **Subject-end declarations** — who may be the *source* of a relation (a root `Thing`?).
2. **Inheritance path** — through `is-a` AND `subtype-of` downward (current choice); revisit
if instances-of-instances as candidates surprises.
3. **Bootstrap / meta-circularity** — `is-a` needs `is-a`; seed relation-posts + `Type is-a
Type`(?) idempotently, as the type seed already is.
4. **Cost** — `reach-down` is a BFS of direct-edge scans; fine for a small blog, revisit with
a `lib/relations` transitive query if the graph grows.