Skip to content

PR-08: parity error-state API (#479)#508

Open
charliecreates[bot] wants to merge 1 commit intocharlie/issue-454-v2-pr08-time-sclk-group07from
charlie/issue-454-v2-pr09-error-state-group08
Open

PR-08: parity error-state API (#479)#508
charliecreates[bot] wants to merge 1 commit intocharlie/issue-454-v2-pr08-time-sclk-group07from
charlie/issue-454-v2-pr09-error-state-group08

Conversation

@charliecreates
Copy link
Contributor

Stack step PR-08 in the issue #454 migration chain.

@github-actions
Copy link

orrery preview: https://pr-508.orrery-c4f.pages.dev/

Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The biggest issue is that error.chkout is being made deterministic by implicitly calling chkin inside the method implementation (both TS and native), which changes semantics and can mask real parity differences. The new native/TS handlers are also inconsistent about rejecting extra arguments (several accept >= 1 rather than == 1). Finally, the new v2 method specs use overly-generic result schemas that don’t match the actual return shapes (boolean/string/null), reducing contract signal.

Additional notes (1)
  • Maintainability | packages/parity-checking/src/guards/validateCompleteness.ts:7-8
    The completeness baselines (BASELINE_CANONICAL_METHOD_COVERAGE, MAX_BASELINE_DENYLIST_SIZE) are updated as raw numbers. This pattern tends to create churn and provides limited signal when the catalog changes (it forces updating constants rather than deriving expectations).

Since you already compute the summary in tests, consider making the guard check derive from the contract catalog at runtime (or pin against a snapshot file) so changes are reviewable as data diffs instead of magic-number edits.

Summary of changes

What changed

✅ Added v2 method coverage for CSPICE error-state APIs

  • New method specs under packages/parity-checking/specs/methods/error/*@v2.yml for:
    • error.failed, error.reset, error.getmsg, error.setmsg, error.sigerr, error.chkin, error.chkout
  • New legacy workflow descriptor: packages/parity-checking/workflows/legacy/error.basic@v1.yml

🧩 Enabled legacy runner support in native CSPICE harness

  • packages/parity-checking/native/src/cspice_runner.c
    • Added new CallId variants CALL_ERROR_*
    • Registered dispatch strings like "error.getmsg" in parse_call_id()
    • Implemented switch cases to invoke corresponding CSPICE calls and emit JSON responses

🔁 Wired up TSPICE runner dispatch for the new methods

  • packages/parity-checking/src/runners/tspiceRunner.ts
    • Added dispatch handlers for the error.* methods, including deterministic priming via setmsg() for reset, getmsg, and sigerr

📉 Reduced denylist and updated baseline/test expectations

  • Removed error.* entries from:
    • packages/parity-checking/catalogs/parity-denylist.json
    • packages/parity-checking/catalogs/parity-denylist.ts
  • Updated completeness baselines and test assertions:
    • Coverage 136 → 143
    • Denylist size 37 → 30
    • Files touched: validateCompleteness.ts, completenessGuard.test.ts, parity.e2e.test.ts, parityScenarioCoverage.test.ts

Comment on lines +358 to +371
"error.getmsg": (backend, args) => {
if (typeof args[0] !== "string") {
invalidArgs(`error.getmsg expects args[0] to be a string (got ${formatValue(args[0])})`);
}
if (args[0] !== "SHORT" && args[0] !== "LONG" && args[0] !== "EXPLAIN") {
invalidArgs(
`error.getmsg expects args[0] to be "SHORT", "LONG", or "EXPLAIN" (got ${formatValue(args[0])})`,
);
}

// Prime a stable long-message payload because getmsg() reads global buffers.
backend.setmsg("TSPICE parity getmsg long message");
return backend.getmsg(args[0]);
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error.getmsg validates only args[0] but doesn’t reject extra arguments. In the native runner, CALL_ERROR_GETMSG only checks tokens[argsTok].size < 1, so extra args are silently accepted there too. This creates inconsistent API behavior versus other handlers like error.failed/error.reset which explicitly enforce zero args.

Given these are contract-level method specs, it’s better to be strict and fail fast on unexpected parameters to keep parity deterministic and avoid tests accidentally passing while sending junk inputs.

Suggestion

Tighten argument-count validation to match a strict contract surface. For example:

"error.getmsg": (backend, args) => {
  if (args.length !== 1 || typeof args[0] !== "string") {
    invalidArgs(
      `error.getmsg expects args[0]=string and no extra args (got ${formatValue(args)})`,
    );
  }
  if (args[0] !== "SHORT" && args[0] !== "LONG" && args[0] !== "EXPLAIN") {
    invalidArgs(
      `error.getmsg expects args[0] to be "SHORT", "LONG", or "EXPLAIN" (got ${formatValue(args[0])})`,
    );
  }
  backend.setmsg("TSPICE parity getmsg long message");
  return backend.getmsg(args[0]);
},

Then mirror the same strictness in cspice_runner.c for CALL_ERROR_GETMSG (and similarly for SETMSG/SIGERR/CHKIN/CHKOUT) by rejecting tokens[argsTok].size != 1.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines +400 to +409
"error.chkout": (backend, args) => {
if (typeof args[0] !== "string") {
invalidArgs(`error.chkout expects args[0] to be a string (got ${formatValue(args[0])})`);
}

// `chkout` is deterministic after matching `chkin` within the same case.
backend.chkin(args[0]);
backend.chkout(args[0]);
return null;
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error.chkout is implemented by calling chkin_c(name) and then chkout_c(name) to force determinism. However, the API name suggests it should only perform chkout (and error if the call stack is unbalanced). Auto-priming inside the method changes semantics and can mask real contract behavior differences.

If the goal is determinism for the spec case, the priming should live in the workflow (pre-steps) or in the YAML case setup, not in the method implementation itself.

Suggestion

Move the chkin priming out of the error.chkout implementation and into a deterministic workflow/case setup.

Options:

  1. Introduce a workflow for these error-state tests (e.g. error.basic@v2) with steps:

    • invokeLegacyCall for error.chkin
    • invokeLegacyCall for error.chkout
  2. If the framework supports it, add a before/setup step to the error.chkout.basic case rather than altering the dispatch.

Then error.chkout dispatch becomes:

"error.chkout": (backend, args) => {
  if (typeof args[0] !== "string") {
    invalidArgs(`error.chkout expects args[0] to be a string (got ${formatValue(args[0])})`);
  }
  backend.chkout(args[0]);
  return null;
},

Mirror this in the native runner by removing the chkin_c(name) prime.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines +351 to +356
"error.reset": (backend) => {
// Prime mutable error buffers so reset() is exercised deterministically.
backend.setmsg("TSPICE parity reset prelude");
backend.reset();
return null;
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error.reset primes the long-message buffer via setmsg_c(...) but doesn’t verify that priming didn’t itself signal a SPICE error before calling reset_c(). You do check failed_c() after setmsg_c() in the native runner; the TS runner currently doesn’t. If backend.setmsg() can fail (and parity is specifically about error-state APIs), this can cause confusing downstream failures (e.g., reset failing due to a prior failure you didn’t surface).

This is especially relevant because error.reset is often used to clear state; if the prelude fails, you’re no longer testing reset() from a known precondition.

Suggestion

After priming via backend.setmsg(...), explicitly check/clear error state (depending on your backend API). For example, if the backend exposes failed() and/or throws on failure, enforce parity with the native behavior.

One possible approach:

"error.reset": (backend) => {
  backend.setmsg("TSPICE parity reset prelude");
  if (backend.failed()) {
    // surface as a deterministic failure rather than continuing
    throw new Error("SPICE error in setmsg (reset prelude)");
  }
  backend.reset();
  return null;
},

If your runner uses structured errors instead of throwing, adapt accordingly.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +3066 to +3354
case CALL_ERROR_GETMSG: {
if (tokens[argsTok].size < 1) {
write_error_json_ex("invalid_args", "error.getmsg expects args[0]=string",
NULL, NULL, NULL, NULL);
goto done;
}

int whichTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
if (whichTok < 0 || whichTok >= tokenCount ||
tokens[whichTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args",
"error.getmsg expects args[0] to be a string", NULL,
NULL, NULL, NULL);
goto done;
}

char *which = NULL;
strDetail[0] = '\0';
jsmn_strdup_err_t whichErr =
jsmn_strdup(input, &tokens[whichTok], &which, strDetail,
sizeof(strDetail));
if (whichErr != JSMN_STRDUP_OK) {
if (whichErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

if (strcmp(which, "SHORT") != 0 && strcmp(which, "LONG") != 0 &&
strcmp(which, "EXPLAIN") != 0) {
free(which);
write_error_json_ex(
"invalid_args",
"error.getmsg expects args[0] to be \"SHORT\", \"LONG\", or \"EXPLAIN\"",
NULL, NULL, NULL, NULL);
goto done;
}

// Prime a stable long-message payload because getmsg() reads global buffers.
setmsg_c("TSPICE parity getmsg long message");
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
free(which);
write_error_json("SPICE error in setmsg (getmsg prime)", shortMsg, longMsg,
traceMsg);
goto done;
}

SpiceChar msg[1841];
msg[0] = '\0';
getmsg_c(which, (SpiceInt)sizeof(msg), msg);
free(which);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
write_error_json("SPICE error in getmsg", shortMsg, longMsg, traceMsg);
goto done;
}

fputs("{\"ok\":true,\"result\":\"", stdout);
json_print_escaped(msg);
fputs("\"}\n", stdout);
goto done;
}

case CALL_ERROR_SETMSG: {
if (tokens[argsTok].size < 1) {
write_error_json_ex("invalid_args", "error.setmsg expects args[0]=string",
NULL, NULL, NULL, NULL);
goto done;
}

int msgTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
if (msgTok < 0 || msgTok >= tokenCount || tokens[msgTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args",
"error.setmsg expects args[0] to be a string", NULL,
NULL, NULL, NULL);
goto done;
}

char *msg = NULL;
strDetail[0] = '\0';
jsmn_strdup_err_t msgErr =
jsmn_strdup(input, &tokens[msgTok], &msg, strDetail, sizeof(strDetail));
if (msgErr != JSMN_STRDUP_OK) {
if (msgErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

setmsg_c(msg);
free(msg);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
write_error_json("SPICE error in setmsg", shortMsg, longMsg, traceMsg);
goto done;
}

fputs("{\"ok\":true,\"result\":null}\n", stdout);
goto done;
}

case CALL_ERROR_SIGERR: {
if (tokens[argsTok].size < 1) {
write_error_json_ex("invalid_args", "error.sigerr expects args[0]=string",
NULL, NULL, NULL, NULL);
goto done;
}

int shortTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
if (shortTok < 0 || shortTok >= tokenCount ||
tokens[shortTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args",
"error.sigerr expects args[0] to be a string", NULL,
NULL, NULL, NULL);
goto done;
}

char *shortMsgArg = NULL;
strDetail[0] = '\0';
jsmn_strdup_err_t shortErr =
jsmn_strdup(input, &tokens[shortTok], &shortMsgArg, strDetail,
sizeof(strDetail));
if (shortErr != JSMN_STRDUP_OK) {
if (shortErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

// Preserve deterministic long-message content in the raised SPICE error.
setmsg_c("TSPICE parity sigerr long message");
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
free(shortMsgArg);
write_error_json("SPICE error in setmsg (sigerr prelude)", shortMsg,
longMsg, traceMsg);
goto done;
}

sigerr_c(shortMsgArg);
free(shortMsgArg);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
write_error_json("SPICE error in sigerr", shortMsg, longMsg, traceMsg);
goto done;
}

fputs("{\"ok\":true,\"result\":null}\n", stdout);
goto done;
}

case CALL_ERROR_CHKIN: {
if (tokens[argsTok].size < 1) {
write_error_json_ex("invalid_args", "error.chkin expects args[0]=string",
NULL, NULL, NULL, NULL);
goto done;
}

int nameTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
if (nameTok < 0 || nameTok >= tokenCount || tokens[nameTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args",
"error.chkin expects args[0] to be a string", NULL,
NULL, NULL, NULL);
goto done;
}

char *name = NULL;
strDetail[0] = '\0';
jsmn_strdup_err_t nameErr =
jsmn_strdup(input, &tokens[nameTok], &name, strDetail, sizeof(strDetail));
if (nameErr != JSMN_STRDUP_OK) {
if (nameErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

chkin_c(name);
free(name);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
write_error_json("SPICE error in chkin", shortMsg, longMsg, traceMsg);
goto done;
}

fputs("{\"ok\":true,\"result\":null}\n", stdout);
goto done;
}

case CALL_ERROR_CHKOUT: {
if (tokens[argsTok].size < 1) {
write_error_json_ex("invalid_args", "error.chkout expects args[0]=string",
NULL, NULL, NULL, NULL);
goto done;
}

int nameTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
if (nameTok < 0 || nameTok >= tokenCount || tokens[nameTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args",
"error.chkout expects args[0] to be a string", NULL,
NULL, NULL, NULL);
goto done;
}

char *name = NULL;
strDetail[0] = '\0';
jsmn_strdup_err_t nameErr =
jsmn_strdup(input, &tokens[nameTok], &name, strDetail, sizeof(strDetail));
if (nameErr != JSMN_STRDUP_OK) {
if (nameErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

// `chkout` is deterministic after a matching `chkin` in this case.
chkin_c(name);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
free(name);
write_error_json("SPICE error in chkin (chkout prime)", shortMsg, longMsg,
traceMsg);
goto done;
}

chkout_c(name);
free(name);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
write_error_json("SPICE error in chkout", shortMsg, longMsg, traceMsg);
goto done;
}

fputs("{\"ok\":true,\"result\":null}\n", stdout);
goto done;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CALL_ERROR_GETMSG branch accepts at least one arg but doesn’t reject extra args. That makes the legacy/native runner more permissive than it probably should be and risks parity drift if a caller mistakenly supplies additional parameters.

Related: several of the new error handlers (SETMSG, SIGERR, CHKIN, CHKOUT) similarly check < 1 but not != 1.

Suggestion

Make the argument count checks strict for the new error.* calls.

Example pattern:

if (tokens[argsTok].size != 1) {
  write_error_json_ex("invalid_args", "error.getmsg expects exactly 1 argument", NULL,
                      NULL, NULL, NULL);
  goto done;
}

Apply similarly:

  • error.setmsg, error.sigerr, error.chkin, error.chkoutsize != 1
  • error.failed, error.resetsize != 0 (already done)

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines +3296 to +3354
case CALL_ERROR_CHKOUT: {
if (tokens[argsTok].size < 1) {
write_error_json_ex("invalid_args", "error.chkout expects args[0]=string",
NULL, NULL, NULL, NULL);
goto done;
}

int nameTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
if (nameTok < 0 || nameTok >= tokenCount || tokens[nameTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args",
"error.chkout expects args[0] to be a string", NULL,
NULL, NULL, NULL);
goto done;
}

char *name = NULL;
strDetail[0] = '\0';
jsmn_strdup_err_t nameErr =
jsmn_strdup(input, &tokens[nameTok], &name, strDetail, sizeof(strDetail));
if (nameErr != JSMN_STRDUP_OK) {
if (nameErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

// `chkout` is deterministic after a matching `chkin` in this case.
chkin_c(name);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
free(name);
write_error_json("SPICE error in chkin (chkout prime)", shortMsg, longMsg,
traceMsg);
goto done;
}

chkout_c(name);
free(name);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg),
traceMsg, sizeof(traceMsg));
write_error_json("SPICE error in chkout", shortMsg, longMsg, traceMsg);
goto done;
}

fputs("{\"ok\":true,\"result\":null}\n", stdout);
goto done;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same semantic concern as in the TS runner: CALL_ERROR_CHKOUT primes with chkin_c(name) before calling chkout_c(name). That makes error.chkout succeed even when it should fail for an unbalanced call stack, meaning this implementation can’t detect parity issues around the actual chkout behavior.

Determinism should be achieved via explicit setup steps rather than by changing what the method does.

Suggestion

Remove the implicit chkin_c(name) priming from the CALL_ERROR_CHKOUT handler and instead ensure the parity case/workflow performs a error.chkin call before error.chkout.

Minimal change:

  • Delete the chkin_c(name) block (and its error handling)
  • Keep only chkout_c(name)
  • Update error/chkout@v2.yml workflow/case to include a prior error.chkin step if needed

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Comment on lines +1 to +19
schemaVersion: 2
manifest:
id: methods/error/failed@v2
kind: method
contract:
contractMethod: error.failed
canonicalMethod: error.failed
aliases: []
result:
type: object
properties: {}
workflow:
steps:
- op: invokeLegacyCall
cases:
- id: error.failed.clean-state
args: []
expect:
ok: true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The v2 specs define result as an object with empty properties for methods that clearly return null or a boolean/string (e.g., error.failed returns a boolean; error.getmsg returns a string; error.reset returns null). This weakens contract guarantees and makes it harder for the parity framework to validate output shapes.

Even if the engine treats legacy calls as loosely typed, the spec should reflect reality to prevent future regressions.

Suggestion

Update the contract.result schemas to match the actual outputs:

  • error.failed → boolean
  • error.getmsg → string
  • error.reset, error.setmsg, error.sigerr, error.chkin, error.chkout → null (or a type: "null" if supported by your schema dialect)

Example:

contract:
  result:
    type: boolean

and

contract:
  result:
    type: string

and

contract:
  result:
    type: "null"

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 22, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant