plan: schema-camelcase-fix — 4-task atomic schema-tag migration (refs #30)
Iteration plan derived from `docs/specs/2026-05-21-schema-camelcase-fix.md` (commit55ce6d0, amended). Single-iteration milestone, four tasks: - **Task 1 (RED)**: add `lam_serialises_with_kebab_keys` pin to `design_schema_drift.rs` + extend `anchor_in_jsonc_block` walk to require the new tag spellings inside `data-model.md`'s `lam` fenced block. Verifies pin fires RED on current state. - **Task 2 (GREEN atomic schema swap)**: 10-step coupled migration of `ast.rs:492,494` + `workspace.rs:1925-1926` + `examples/test_loop_binder_captured_by_lambda.ail.json` + `experiments/.../master/examples/fn_with_lambda.ail.json` + `design/contracts/data-model.md:142-147`. All five files MUST move together — changing only `ast.rs` makes the JSON literals undeserialisable (no `#[serde(default)]` on `param_tys`/`ret_ty`, so the renamed-away key is a hard error). - **Task 3 (GREEN-2 rustdoc honesty)**: three pure-prose edits in `ast.rs:8`, `parse.rs:81`, `check/lib.rs:1707`. Decoupled from the schema swap — no compile or test consequence. - **Task 4 (Verify)**: exhaustive negative grep, full workspace test suite, round-trip invariance, forbidden-files check. Plan pre-recon by `ailang-plan-recon` (DONE_WITH_CONCERNS) caught the data-model.md + rustdoc layer the brainstorm initially missed — spec was amended in commit55ce6d0+ re-grounding-check PASS (9 claims ratified, 2 of them via exhaustive negative grep as "no-test-pins-this" Boss-overrideable shape). Self-review: 8/8 (spec coverage, no placeholders, name consistency, step granularity 2-5 min, no commit steps, pin/replacement contiguity, compile-gate threading complete within Task 2, verification filters all resolve to named real tests).
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
# schema-camelcase-fix — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/2026-05-21-schema-camelcase-fix.md`
|
||||
> (commit `55ce6d0`, amended after plan-recon)
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
||||
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Rename the two camelCase JSON tags `paramTypes` / `retType`
|
||||
on `Term::Lam` to the kebab-case `param-types` / `ret-type`,
|
||||
migrate every checked-in code/contract reference in the same
|
||||
iteration, and pin the new schema shape with a `design_schema_drift`
|
||||
assertion. After this iteration the AILang canonical-JSON schema
|
||||
has zero camelCase tags. Closes Gitea #30.
|
||||
|
||||
**Architecture:** Single-iteration milestone. Four layers
|
||||
touched: (1) `#[serde(rename)]` strings on `Term::Lam` in
|
||||
`crates/ailang-core/src/ast.rs`; (2) one in-source test JSON-literal
|
||||
in `crates/ailang-core/src/workspace.rs`; (3) two `.ail.json` fixtures
|
||||
+ one canonical-schema contract document
|
||||
(`design/contracts/data-model.md`); (4) three rustdoc strings that
|
||||
describe present-state schema vocabulary. The Form-A surface
|
||||
(`crates/ailang-surface/`) is structurally untouched — Form-A uses
|
||||
`(params (typed ...)) (ret ...)`, not the renamed tags. No hash-pin
|
||||
refresh is required (no `.ail` module currently pinned in
|
||||
`hash_pin.rs` contains a `(lam ...)` term — verified per-file).
|
||||
|
||||
**Tech Stack:** `serde_json` rename attributes, AILang `Term::Lam`
|
||||
schema, `design_schema_drift.rs` anchor walker, `anchor_in_jsonc_block`
|
||||
helper.
|
||||
|
||||
---
|
||||
|
||||
## Files this plan creates or modifies
|
||||
|
||||
- Modify: `crates/ailang-core/tests/design_schema_drift.rs` — add
|
||||
one new `#[test] fn` (the schema-shape pin) and extend the
|
||||
existing `anchor_in_jsonc_block` walk to require the new tag
|
||||
spellings inside the `Term::Lam` fenced JSON block of
|
||||
`data-model.md`.
|
||||
- Modify: `crates/ailang-core/src/ast.rs:492,494` — two
|
||||
`#[serde(rename)]` string-literal swaps.
|
||||
- Modify: `crates/ailang-core/src/ast.rs:8` — module-level `//!`
|
||||
rustdoc enumerates rename targets.
|
||||
- Modify: `crates/ailang-core/src/workspace.rs:1925-1926` — in-source
|
||||
JSON literal inside `#[test] fn ct1_validator_walks_lam_embedded_types`
|
||||
(function at line 1911).
|
||||
- Modify: `crates/ailang-surface/src/parse.rs:81` — module-level
|
||||
`//!` rustdoc mentions `lam`'s `paramTypes` carry.
|
||||
- Modify: `crates/ailang-check/src/lib.rs:1707` — `///` rustdoc on
|
||||
the InstanceMethod routing helper.
|
||||
- Modify: `design/contracts/data-model.md:142-147` — fenced JSON
|
||||
block describing the `Term::Lam` schema.
|
||||
- Modify: `examples/test_loop_binder_captured_by_lambda.ail.json`
|
||||
— two key renames, one per line (currently lines 32-33; verify
|
||||
by grep before editing).
|
||||
- Modify: `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json`
|
||||
— two key renames, one per line (currently lines 25-26).
|
||||
|
||||
**Forbidden touches** (per spec acceptance criterion 7):
|
||||
- `docs/plans/2026-05-10-23.3-*.md`, `docs/plans/2026-05-09-22b3-monomorphisation.md`,
|
||||
`docs/plans/2026-05-09-22c-user-class-e2e.md`, `docs/plans/24.2.md`
|
||||
- `experiments/2026-05-12-cross-model-authoring/master/spec.md`
|
||||
- `experiments/2026-05-12-cross-model-authoring/rendered/*.md`
|
||||
- `experiments/2026-05-12-cross-model-authoring/runs/**`
|
||||
|
||||
These embed the old tag names but are frozen historical artefacts.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: RED — pin the new schema shape
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-core/tests/design_schema_drift.rs`
|
||||
|
||||
The pin lives in `design_schema_drift.rs` (not `schema_coverage.rs`)
|
||||
because that file already constructs `Term::Lam` exemplars at
|
||||
L121-129, already includes `data-model.md` via `include_str!`
|
||||
(L22, constant name `DATA_MODEL`), and already calls the
|
||||
`anchor_in_jsonc_block` helper (L34-47, called at L184) for fenced-
|
||||
block walks. The new pin and the data-model anchor extension belong
|
||||
in the same test file as the rest of the schema-drift family.
|
||||
|
||||
- [ ] **Step 1: Read the existing pin family**
|
||||
|
||||
Read `crates/ailang-core/tests/design_schema_drift.rs`. Note:
|
||||
- the `Term::Lam` exemplar around L121-129
|
||||
- the `anchor_in_jsonc_block` helper at L34-47
|
||||
- the existing `design_md_anchors_every_term_variant` test (where
|
||||
the new tag anchors will land)
|
||||
|
||||
Step exists to ground the new test in the file's prevailing shape;
|
||||
no edit.
|
||||
|
||||
- [ ] **Step 2: Add the schema-shape pin**
|
||||
|
||||
Append at end of `crates/ailang-core/tests/design_schema_drift.rs`
|
||||
(or co-locate with the other `Term::Lam`-touching tests at L121-129
|
||||
— pick the location with the closest sibling test):
|
||||
|
||||
```rust
|
||||
/// Schema-shape pin: the JSON serialisation of `Term::Lam` must
|
||||
/// emit kebab-case tags `"param-types"` and `"ret-type"`, and
|
||||
/// must NOT emit the old camelCase `"paramTypes"` / `"retType"`.
|
||||
/// Pinned because the closes-#30 milestone made the rename a
|
||||
/// schema-stability invariant: a future regression that flips
|
||||
/// the `#[serde(rename)]` string back to camelCase must fire RED
|
||||
/// here, not slip through.
|
||||
#[test]
|
||||
fn lam_serialises_with_kebab_keys() {
|
||||
use ailang_core::ast::*;
|
||||
|
||||
let lam = Term::Lam {
|
||||
params: vec!["x".into()],
|
||||
param_tys: vec![Type::int()],
|
||||
ret_ty: Box::new(Type::int()),
|
||||
effects: vec![],
|
||||
body: Box::new(Term::Var { name: "x".into() }),
|
||||
};
|
||||
let v = serde_json::to_value(&lam).expect("Term::Lam serialises");
|
||||
let obj = v.as_object().expect("Term::Lam serialises as object");
|
||||
|
||||
assert!(
|
||||
obj.contains_key("param-types"),
|
||||
"Term::Lam must emit `param-types` key; got keys {:?}",
|
||||
obj.keys().collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(
|
||||
obj.contains_key("ret-type"),
|
||||
"Term::Lam must emit `ret-type` key; got keys {:?}",
|
||||
obj.keys().collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(
|
||||
!obj.contains_key("paramTypes"),
|
||||
"Term::Lam must NOT emit old camelCase `paramTypes` key",
|
||||
);
|
||||
assert!(
|
||||
!obj.contains_key("retType"),
|
||||
"Term::Lam must NOT emit old camelCase `retType` key",
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend the data-model.md anchor walk**
|
||||
|
||||
Find the existing test that uses `anchor_in_jsonc_block` to assert
|
||||
fenced-block content (recon names `design_md_anchors_every_term_variant`
|
||||
at the test name level, and `anchor_in_jsonc_block` calls at L184).
|
||||
Read the existing anchor list, identify where the `lam` block
|
||||
anchors live, and add two new anchor assertions for the same `lam`
|
||||
fenced JSON block:
|
||||
|
||||
```rust
|
||||
assert!(
|
||||
anchor_in_jsonc_block(DATA_MODEL, "lam", "\"param-types\""),
|
||||
"data-model.md `lam` fenced block must list `param-types` key",
|
||||
);
|
||||
assert!(
|
||||
anchor_in_jsonc_block(DATA_MODEL, "lam", "\"ret-type\""),
|
||||
"data-model.md `lam` fenced block must list `ret-type` key",
|
||||
);
|
||||
```
|
||||
|
||||
If the existing test asserts anchors via a `for (variant, anchor) in &[…]` table, add the two rows there instead of two
|
||||
standalone `assert!`s — match the local shape. The implementer
|
||||
reads the file to confirm the local shape before editing.
|
||||
|
||||
- [ ] **Step 4: Run the new pin and verify it FAILS**
|
||||
|
||||
Run: `cargo test -p ailang-core --test design_schema_drift lam_serialises_with_kebab_keys 2>&1 | tail -30`
|
||||
|
||||
Expected: FAIL. The current `ast.rs:492,494` emit `paramTypes` /
|
||||
`retType`, so `obj.contains_key("param-types")` is false. The
|
||||
data-model anchor extension also FAILs because lines 142-147 still
|
||||
carry camelCase. Both RED conditions confirm we are pinning a real
|
||||
property the milestone is about to make true.
|
||||
|
||||
If the test unexpectedly passes, STOP — the assumption "current
|
||||
state emits camelCase" is wrong; re-read `ast.rs:490-499` before
|
||||
proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: GREEN-1 — atomic schema-coupled swap
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-core/src/ast.rs:492` (line `#[serde(rename = "paramTypes")]`)
|
||||
- Modify: `crates/ailang-core/src/ast.rs:494` (line `#[serde(rename = "retType")]`)
|
||||
- Modify: `crates/ailang-core/src/workspace.rs:1925-1926`
|
||||
- Modify: `examples/test_loop_binder_captured_by_lambda.ail.json`
|
||||
- Modify: `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json`
|
||||
- Modify: `design/contracts/data-model.md:142-147`
|
||||
|
||||
These files MUST move together. Changing only `ast.rs` makes any
|
||||
JSON literal containing the old tags undeserialisable: serde
|
||||
`Term::Lam` has no `#[serde(default)]` on `param_tys` / `ret_ty`,
|
||||
so an absent (renamed-away) key is a hard deserialise error. The
|
||||
workspace.rs test, the two `.ail.json` fixtures, and the
|
||||
data-model.md fenced JSON block all participate in the same
|
||||
serialiser/deserialiser contract — they migrate atomically, in a
|
||||
single task, with the full `cargo test` gate at the end.
|
||||
|
||||
`data-model.md` is included by `data-model.md` consumer
|
||||
`design_schema_drift.rs` via `include_str!`; the constant is
|
||||
`DATA_MODEL`. After Task 1's anchor extension, leaving the fenced
|
||||
JSON block on camelCase would keep the test RED.
|
||||
|
||||
- [ ] **Step 1: Edit `crates/ailang-core/src/ast.rs:492`**
|
||||
|
||||
Locate line 492:
|
||||
|
||||
```rust
|
||||
#[serde(rename = "paramTypes")]
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```rust
|
||||
#[serde(rename = "param-types")]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Edit `crates/ailang-core/src/ast.rs:494`**
|
||||
|
||||
Locate line 494:
|
||||
|
||||
```rust
|
||||
#[serde(rename = "retType")]
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```rust
|
||||
#[serde(rename = "ret-type")]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Edit `crates/ailang-core/src/workspace.rs:1925-1926`**
|
||||
|
||||
The two lines live inside the test function
|
||||
`ct1_validator_walks_lam_embedded_types` (function attribute `#[test]`
|
||||
at L1911). Find the literal lines:
|
||||
|
||||
```rust
|
||||
"paramTypes": [{ "k": "con", "name": "Ordering" }],
|
||||
"retType": { "k": "con", "name": "Unit" },
|
||||
```
|
||||
|
||||
Replace with (preserve exact indentation and surrounding context):
|
||||
|
||||
```rust
|
||||
"param-types": [{ "k": "con", "name": "Ordering" }],
|
||||
"ret-type": { "k": "con", "name": "Unit" },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Edit `examples/test_loop_binder_captured_by_lambda.ail.json`**
|
||||
|
||||
Grep first to confirm exact occurrences:
|
||||
|
||||
Run: `grep -n '"paramTypes"\|"retType"' examples/test_loop_binder_captured_by_lambda.ail.json`
|
||||
|
||||
Expected: 2 hits, one with `"paramTypes"`, one with `"retType"`,
|
||||
on consecutive lines (currently 32-33; verify).
|
||||
|
||||
For each line, replace `"paramTypes"` → `"param-types"` and
|
||||
`"retType"` → `"ret-type"`. No other content changes.
|
||||
|
||||
- [ ] **Step 5: Edit `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json`**
|
||||
|
||||
Grep first:
|
||||
|
||||
Run: `grep -n '"paramTypes"\|"retType"' experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json`
|
||||
|
||||
Expected: 2 hits on consecutive lines (currently 25-26; verify).
|
||||
|
||||
Same rename as Step 4.
|
||||
|
||||
- [ ] **Step 6: Edit `design/contracts/data-model.md:142-147`**
|
||||
|
||||
The fenced JSON block currently reads (lines 142-147):
|
||||
|
||||
```
|
||||
{ "t": "lam",
|
||||
"params": ["<id>"...],
|
||||
"paramTypes": [Type...],
|
||||
"retType": Type,
|
||||
"effects": ["<id>"...],
|
||||
"body": Term }
|
||||
```
|
||||
|
||||
Replace lines 144-145 with:
|
||||
|
||||
```
|
||||
"param-types": [Type...],
|
||||
"ret-type": Type,
|
||||
```
|
||||
|
||||
The surrounding `{ "t": "lam", ...` opener, `"params"` line,
|
||||
`"effects"` line, and `"body"` line are untouched. Verify the
|
||||
fenced-block boundaries (` ``` ` markers) above and below remain
|
||||
intact.
|
||||
|
||||
- [ ] **Step 7: Build the workspace to catch any deserialisation regression**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | tail -20`
|
||||
|
||||
Expected: clean build, no new warnings.
|
||||
|
||||
- [ ] **Step 8: Run the schema-shape pin (now expected GREEN)**
|
||||
|
||||
Run: `cargo test -p ailang-core --test design_schema_drift lam_serialises_with_kebab_keys 2>&1 | tail -10`
|
||||
|
||||
Expected: PASS (Task 1's assertion now holds).
|
||||
|
||||
- [ ] **Step 9: Run the extended data-model anchor walk**
|
||||
|
||||
Run: `cargo test -p ailang-core --test design_schema_drift design_md_anchors_every_term_variant 2>&1 | tail -10`
|
||||
|
||||
Expected: PASS (the data-model.md fenced JSON block now carries
|
||||
the new tag anchors).
|
||||
|
||||
- [ ] **Step 10: Run the workspace ct1 test**
|
||||
|
||||
Run: `cargo test -p ailang-core ct1_validator_walks_lam_embedded_types 2>&1 | tail -10`
|
||||
|
||||
Expected: PASS. This is the in-source test that owns the JSON
|
||||
literal edited in Step 3; if Step 3 missed a key, this test fails
|
||||
with a `serde_json::from_value` error mentioning the missing field.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: GREEN-2 — rustdoc honesty pass
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-core/src/ast.rs:8`
|
||||
- Modify: `crates/ailang-surface/src/parse.rs:81`
|
||||
- Modify: `crates/ailang-check/src/lib.rs:1707`
|
||||
|
||||
These are pure prose edits — no compile or test consequence. They
|
||||
exist solely to keep the rustdoc honest after the rename. Per the
|
||||
Honesty Rule (`design/contracts/honesty-rule.md`), production
|
||||
rustdoc describes present-state vocabulary, not history.
|
||||
|
||||
- [ ] **Step 1: Edit `crates/ailang-core/src/ast.rs:8`**
|
||||
|
||||
Locate line 8 in the module-level `//!` block. Current text:
|
||||
|
||||
```rust
|
||||
//! `fn`, `paramTypes`, `retType`), enum tags (`kind`, `t`, `k`, `p`),
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```rust
|
||||
//! `fn`, `param-types`, `ret-type`), enum tags (`kind`, `t`, `k`, `p`),
|
||||
```
|
||||
|
||||
The surrounding `//!` lines are untouched.
|
||||
|
||||
- [ ] **Step 2: Edit `crates/ailang-surface/src/parse.rs:81`**
|
||||
|
||||
Locate line 81 in the module-level `//!` block. Current text:
|
||||
|
||||
```rust
|
||||
//! - The `lam` form carries `paramTypes`, a `ret` type, and an
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```rust
|
||||
//! - The `lam` form carries `param-types`, a `ret` type, and an
|
||||
```
|
||||
|
||||
If the line wraps differently in the local file (the carrier word
|
||||
`paramTypes` may sit on its own line or break across lines after
|
||||
the comma), edit the contiguous `paramTypes` occurrence; do NOT
|
||||
soft-wrap split the new `param-types` across two lines.
|
||||
|
||||
- [ ] **Step 3: Edit `crates/ailang-check/src/lib.rs:1707`**
|
||||
|
||||
Locate line 1707. Current text:
|
||||
|
||||
```rust
|
||||
/// convention a `Term::Lam` carrying its own `paramTypes` / `retType`
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```rust
|
||||
/// convention a `Term::Lam` carrying its own `param-types` / `ret-type`
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run a doc-build to catch broken intra-doc links**
|
||||
|
||||
Run: `cargo doc --workspace --no-deps 2>&1 | grep -E "warning|error" | head -20`
|
||||
|
||||
Expected: no NEW warnings (the workspace already carries a
|
||||
known-stable warning count; flag any new diagnostic that mentions
|
||||
`paramTypes` / `retType` / `param-types` / `ret-type`).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Final verification
|
||||
|
||||
**Files:** none modified — verification only.
|
||||
|
||||
- [ ] **Step 1: Exhaustive grep — nothing left on the old tags**
|
||||
|
||||
Run: `grep -rn '"paramTypes"\|"retType"\|paramTypes\|retType' crates/ examples/ design/ runtime/ --include='*.rs' --include='*.json' --include='*.md' 2>/dev/null`
|
||||
|
||||
Expected output (and only this output):
|
||||
- `docs/plans/*.md` matches in the historic plan files listed under "Forbidden touches" above.
|
||||
- `experiments/2026-05-12-cross-model-authoring/master/spec.md` matches.
|
||||
- `experiments/2026-05-12-cross-model-authoring/rendered/*.md` matches.
|
||||
- `experiments/2026-05-12-cross-model-authoring/runs/**` matches.
|
||||
- ZERO matches under `crates/`, `examples/`, `runtime/`, or `design/`.
|
||||
|
||||
Any match outside the expected set is a missed touch-point; resolve
|
||||
it by adding the rename, then re-run the full test suite (Step 2).
|
||||
|
||||
- [ ] **Step 2: Run the workspace test suite**
|
||||
|
||||
Run: `cargo test --workspace 2>&1 | tail -25`
|
||||
|
||||
Expected: every crate's test summary shows `0 failed`. The total
|
||||
test-group count must equal the pre-iter baseline of 90
|
||||
(unchanged — Task 1 adds one new test inside an existing test crate,
|
||||
which does not change the test-group count).
|
||||
|
||||
- [ ] **Step 3: Round-trip invariance check**
|
||||
|
||||
Run: `cargo test -p ailang-surface --test round_trip 2>&1 | tail -10`
|
||||
|
||||
Expected: PASS. This is the Form-A-unchanged gate; if it fails
|
||||
the spec's Layer-2 claim ("Form-A never used these tag names") was
|
||||
wrong and the iteration scope was mis-sized.
|
||||
|
||||
- [ ] **Step 4: Confirm no forbidden files were touched**
|
||||
|
||||
Run: `git diff --name-only HEAD 2>&1 | grep -E "docs/plans/|experiments/.*/(master/spec\.md|rendered/|runs/)" | head -20`
|
||||
|
||||
Expected: empty output. If any line appears, the implementer
|
||||
mutated a forbidden file — `git checkout -- <path>` the change.
|
||||
|
||||
- [ ] **Step 5: Sanity check the new pin would actually go RED**
|
||||
|
||||
Mental check (no `git revert` — main is sacrosanct): the pin in
|
||||
Task 1 was confirmed RED in Task 1 Step 4 before the swap. Step 8
|
||||
of Task 2 confirmed it GREEN after the swap. Therefore the pin is
|
||||
a real RED/GREEN bisector. No additional action — this step exists
|
||||
to mark the property as audited.
|
||||
|
||||
- [ ] **Step 6: Status summary for Boss commit**
|
||||
|
||||
Print a one-line summary of the resulting `git status`:
|
||||
|
||||
Run: `git status --short`
|
||||
|
||||
Expected: nine modified files (the eight listed in "Files this
|
||||
plan creates or modifies" plus the new test slot in
|
||||
`design_schema_drift.rs`), no untracked files of interest. The
|
||||
Boss will inspect the diff and assemble the iter-level commit.
|
||||
Reference in New Issue
Block a user